WidgetKit Timeline을 갱신하는 방법
WidgetKit Timeline을 갱신하는 방법
WidgetKit 위젯은 앱처럼 계속 실행되며 timer를 돌리지 않는다. provider가 “어떤 시각에 어떤 화면을 보여 줄지”를 TimelineEntry 배열로 제출하면 시스템이 이를 소비한다. 예측 가능한 변화는 미래 entry로, 서버·사용자 행동처럼 예측 불가능한 변화는 저장 완료 뒤 WidgetCenter reload 요청으로 처리한다. reload policy가 나타내는 것은 정확한 실행 시각이 아니라 다음 timeline을 요청해도 되는 가장 이른 시점이다.
목차
- #위젯을 작은 앱처럼 만들면 왜 갱신이 어긋날까
- #Timeline은 예약된 화면 상태의 목록이다
- #placeholder와 snapshot과 timeline의 역할
- #세 가지 reload policy 고르기
- #예측 가능한 변화는 여러 entry로 만들기
- #예측할 수 없는 변화는 reload로 알리기
- #저장과 reload의 순서를 지키기
- #네트워크 요청보다 마지막 스냅샷을 우선하기
- #오래된 데이터를 숨기지 않고 표현하기
- #중복 reload와 불필요한 작업 줄이기
- #시간 경계와 타임존을 정확히 계산하기
- #테스트 가능한 TimelineBuilder 만들기
- #운영 환경에서 갱신 문제 진단하기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
위젯을 작은 앱처럼 만들면 왜 갱신이 어긋날까
처음 WidgetKit을 구현할 때 가장 자연스러운 기대는 “1분마다 현재 값을 가져와 다시 그리기”다. 앱 화면이라면 Timer, state update, API polling으로 구현할 수 있다. 하지만 WidgetKit extension은 항상 실행되는 프로세스가 아니다. 시스템이 필요할 때 provider에게 timeline을 요청하고, extension 실행이 끝난 뒤에도 이미 제출된 entry로 화면을 갱신한다.
// 이런 timer가 위젯을 계속 살려 두지는 않는다.
Timer.scheduledTimer(
withTimeInterval: 60,
repeats: true
) { _ in
loadLatestValue()
}
timer가 필요하다는 생각은 “현재 시각에 계산한 화면 하나”만 넘기기 때문에 생긴다. WidgetKit에서는 미래에 어떻게 변할지 아는 값이라면 미래 화면까지 미리 계산해 timeline에 넣는다.
예를 들어 다음 약 복용 시각까지 남은 단계를 표시한다고 해 보자.
09:00 복용까지 2시간
10:00 복용까지 1시간
11:00 복용할 시간
12:00 확인 필요
extension이 매시간 깨어나 계산하는 대신 네 개의 entry를 한 번에 반환할 수 있다.
위젯 갱신은 “언제 코드를 다시 실행할까”보다 “지금 알고 있는 미래 상태를 얼마나 timeline으로 표현할 수 있을까”에서 시작한다.
이 글의 코드는 실제 프로젝트 코드가 아니라, 기록 수와 다음 예정 시간을 보여 주는 가상 위젯 예제로 재구성했다.
Timeline은 예약된 화면 상태의 목록이다
TimelineEntry는 표시할 날짜와 그 날짜에 필요한 렌더링 데이터를 가진다.
struct SummaryEntry: TimelineEntry {
let date: Date
let state: SummaryWidgetState
}
enum SummaryWidgetState: Equatable {
case content(
title: String,
count: Int,
message: String,
generatedAt: Date
)
case empty
case unavailable
}
provider는 entry 배열과 reload policy를 묶은 Timeline을 반환한다.
let timeline = Timeline(
entries: [
SummaryEntry(
date: now,
state: .content(
title: "오늘의 기록",
count: 3,
message: "다음 확인까지 1시간",
generatedAt: now
)
)
],
policy: .after(nextRefresh)
)
completion(timeline)
entry의 date는 데이터가 생성된 시각과 구분한다.
entry.date: 시스템이 이 entry를 표시하기 시작할 기준 시각generatedAt: 원본 snapshot이 생성된 시각nextRefresh: provider가 새 timeline을 요청받고 싶은 가장 이른 시각
세 값을 하나의 Date로 돌려 쓰면 stale 여부와 표시 시점이 뒤섞인다.
flowchart LR
A[TimelineProvider 실행] --> B[공유 snapshot 읽기]
B --> C[미래 Entry 배열 생성]
C --> D[Timeline + policy 제출]
D --> E[WidgetKit이 시각별 Entry 표시]
E --> F{새 timeline 필요}
F -- policy 도달 --> A
F -- 앱의 reload 요청 --> Apolicy에 도달했다고 정확히 그 초에 provider가 실행된다고 가정하지 않는다. 시스템은 여러 조건과 예산을 고려해 실제 시점을 결정한다.
placeholder와 snapshot과 timeline의 역할
TimelineProvider에는 비슷해 보이는 세 진입점이 있다.
| 메서드 | 사용 맥락 | 구현 원칙 |
|---|---|---|
placeholder(in:) |
widget gallery와 redacted UI의 뼈대 | 즉시 반환, 대표 layout |
getSnapshot(in:completion:) |
gallery preview 또는 빠른 현재 모습 | preview면 fixture, 아니면 cached snapshot |
getTimeline(in:completion:) |
실제 시간 흐름 | entry 배열과 reload policy |
placeholder에서 파일이나 network를 기다릴 필요가 없다.
func placeholder(in context: Context) -> SummaryEntry {
SummaryEntry(
date: Date(),
state: .content(
title: "오늘의 기록",
count: 3,
message: "최근 상태를 표시해요",
generatedAt: Date()
)
)
}
snapshot은 preview 여부를 구분한다.
func getSnapshot(
in context: Context,
completion: @escaping (SummaryEntry) -> Void
) {
if context.isPreview {
completion(.preview)
return
}
completion(entryFactory.makeCurrentEntry())
}
gallery preview에 실제 계정 데이터가 나타나게 만들지 않는다. fixture는 레이아웃을 잘 보여 주되 실제 사용자 이름이나 프로젝트 데이터를 복사하지 않는다.
실제 timeline에서는 읽기 실패도 entry로 바꾸고 반드시 completion을 호출한다.
func getTimeline(
in context: Context,
completion: @escaping (Timeline<SummaryEntry>) -> Void
) {
let timeline = timelineBuilder.build(
from: snapshotReader.readResult(),
now: clock.now()
)
completion(timeline)
}
오류 분기에서 completion을 빼먹으면 위젯이 이전 상태에 머문 채 원인 파악이 어려워진다.
세 가지 reload policy 고르기
TimelineReloadPolicy의 대표 선택은 .atEnd, .after(date), .never다.
.atEnd
마지막 entry가 지난 뒤 새 timeline을 요청받고 싶을 때 사용한다.
Timeline(entries: hourlyEntries, policy: .atEnd)
미래 entry를 충분히 제공했고 마지막 이후에는 새 계산이 필요할 때 자연스럽다. entry가 하나뿐이고 그 날짜가 현재라면 사실상 곧 새 갱신을 원한다는 애매한 설계가 될 수 있다.
.after(date)
지정한 날짜 이후 새 timeline을 요청받고 싶을 때 사용한다.
Timeline(
entries: [currentEntry],
policy: .after(nextDayBoundary)
)
“10분마다 정확히 실행” 예약이 아니다. Apple 문서에서 policy는 WidgetKit이 새 timeline을 요청하는 earliest date를 나타낸다.
.never
timeline policy로 자동 갱신을 요청하지 않고 외부 reload에 의존한다.
Timeline(entries: [currentEntry], policy: .never)
사용자 행동이나 push로만 바뀌는 값에 사용할 수 있다. 하지만 reload 신호가 오지 않는 실패 경로가 있다면 위젯이 영구히 오래된 값에 머문다. 안전한 장기 fallback이 필요한지 검토한다.
| 변화 종류 | 기본 전략 | policy 예 |
|---|---|---|
| 시계처럼 예측 가능 | 미래 entry 여러 개 | .atEnd |
| 자정에 count 초기화 | 다음 날짜 경계 계산 | .after(midnight) |
| 앱에서 편집할 때만 변경 | 저장 뒤 reload | .never 또는 긴 fallback |
| 서버 데이터 | cached snapshot + 보수적 재확인 | .after(date) |
| 예측 가능 + 외부 변경 | 미래 entry + reload 병행 | .atEnd와 필요 시 reload |
예측 가능한 변화는 여러 entry로 만들기
현재 snapshot에 다음 예정 시각이 있다면 상태 전환을 미리 만든다.
struct ScheduleTimelineBuilder {
let calendar: Calendar
func entries(
snapshot: WidgetSnapshot,
now: Date
) -> [SummaryEntry] {
var entries = [
makeEntry(
snapshot: snapshot,
at: now,
phase: .upcoming
)
]
if snapshot.nextEventAt > now {
entries.append(
makeEntry(
snapshot: snapshot,
at: snapshot.nextEventAt,
phase: .due
)
)
}
let overdueAt = snapshot.nextEventAt.addingTimeInterval(
60 * 60
)
if overdueAt > now {
entries.append(
makeEntry(
snapshot: snapshot,
at: overdueAt,
phase: .overdue
)
)
}
return entries.sorted { $0.date < $1.date }
}
}
이미 과거인 entry를 무조건 넣지 않고, 같은 날짜의 중복 entry도 제거한다. 생성한 entry가 비어 있지 않은지 invariant로 확인한다.
precondition(!entries.isEmpty)
precondition(
zip(entries, entries.dropFirst()).allSatisfy {
$0.date < $1.date
}
)
미래 entry에는 그 시점에 유효할 것으로 예측 가능한 데이터만 넣는다. 서버에서 몇 분 뒤 count가 어떻게 바뀔지 알 수 없는데 임의로 추정해서는 안 된다.
예측할 수 없는 변화는 reload로 알리기
사용자가 Flutter 앱에서 새 기록을 추가한 것은 기존 timeline을 만들 때 알 수 없었던 사건이다. 앱이 App Group snapshot 저장을 완료한 뒤 관련 widget kind의 reload를 요청한다.
func didCommitWidgetSnapshot(revision: Int) {
WidgetCenter.shared.reloadTimelines(
ofKind: "dev.example.summary-widget"
)
}
kind는 widget configuration을 만들 때 쓴 값과 같아야 한다.
struct SummaryWidget: Widget {
static let kind = "dev.example.summary-widget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: Self.kind,
provider: SummaryProvider()
) { entry in
SummaryWidgetView(entry: entry)
}
}
}
모든 widget을 무조건 갱신하는 reloadAllTimelines()보다 영향을 받는 kind만 요청한다. 여러 사용자 설정 중 일부만 관련된다면 현재 구성 정보를 확인해 reload 필요 여부를 줄일 수 있다.
호출 반환은 홈 화면 렌더 완료 acknowledgment가 아니다. 앱 UI에는 “위젯 업데이트 완료”보다 “표시할 내용을 저장했습니다”처럼 실제로 보장한 범위를 표현한다.
저장과 reload의 순서를 지키기
다음 코드는 경쟁 조건을 만든다.
// 잘못된 순서
WidgetCenter.shared.reloadTimelines(ofKind: SummaryWidget.kind)
try await snapshotWriter.save(snapshot)
WidgetKit이 빠르게 provider를 호출하면 이전 snapshot을 읽어 새 timeline으로 다시 저장할 수 있다.
// 의도한 순서
let savedRevision = try await snapshotWriter.save(snapshot)
WidgetCenter.shared.reloadTimelines(ofKind: SummaryWidget.kind)
return savedRevision
저장 성공과 reload 요청 사이에 앱이 중단될 가능성도 있다. 이 경우 snapshot 자체는 다음 policy 갱신 때 읽을 수 있어야 한다. reload는 consistency를 만드는 transaction commit이 아니라 최신 snapshot을 더 일찍 반영하도록 알리는 최적화에 가깝다.
sequenceDiagram
participant App
participant Group as App Group
participant Center as WidgetCenter
participant Provider
App->>Group: revision 18 원자적 저장
Group-->>App: 성공
App->>Center: reload kind 요청
Note over Center: 실제 실행 시점은 시스템 결정
Center->>Provider: 새 timeline 요청
Provider->>Group: revision 18 읽기
Provider-->>Center: timeline 제출이 공유 저장 구조는 Flutter와 iOS WidgetKit 사이에 데이터 공유하기에서 자세히 다룬다.
네트워크 요청보다 마지막 스냅샷을 우선하기
provider 안에서 매번 network를 호출하면 최신 값을 얻을 수 있을 것 같지만 실패 조건이 많아진다.
- extension 실행 시간 안에 응답하지 않을 수 있다.
- 기기가 offline이거나 저전력 상태일 수 있다.
- 인증 token이 잠금 상태에서 접근 불가능할 수 있다.
- host 앱과 extension이 token refresh를 경쟁할 수 있다.
- 응답 실패 때 보여 줄 데이터가 사라질 수 있다.
표시형 위젯은 host 앱이 동기화한 마지막 성공 snapshot을 우선 읽는 구조가 안정적이다.
func build(
from result: Result<WidgetSnapshot, Error>,
now: Date
) -> Timeline<SummaryEntry> {
switch result {
case .success(let snapshot):
return contentTimeline(snapshot: snapshot, now: now)
case .failure:
return Timeline(
entries: [
SummaryEntry(
date: now,
state: .unavailable
)
],
policy: .after(
now.addingTimeInterval(60 * 30)
)
)
}
}
network가 꼭 필요하더라도 last-known-good snapshot을 먼저 확보한다. 새 요청이 실패하면 cached entry와 다음 재시도 policy를 반환한다. 실패했다고 짧은 간격의 .after를 반복해 시스템을 polling scheduler처럼 사용하지 않는다.
오래된 데이터를 숨기지 않고 표현하기
캐시를 사용하면 freshness가 UI 의미가 된다. generatedAt과 현재 시각 차이로 상태를 나눈다.
enum SnapshotFreshness {
case fresh
case stale
case expired
}
func freshness(
generatedAt: Date,
now: Date
) -> SnapshotFreshness {
let age = now.timeIntervalSince(generatedAt)
switch age {
case ..<(60 * 30):
return .fresh
case ..<(60 * 60 * 24):
return .stale
default:
return .expired
}
}
30분과 24시간은 예시일 뿐이다. 날씨, 일정, 사진, 보안 상태마다 허용 가능한 stale 시간이 다르다.
| freshness | 표시 전략 |
|---|---|
| fresh | 일반 콘텐츠 |
| stale | 마지막 값 + “몇 시간 전” 같은 보조 표시 |
| expired | 행동 유도 또는 제한된 placeholder |
오래된 값을 최신처럼 보여 주는 것이 위험한 도메인이라면 expired 시 값을 숨긴다. 반대로 최근 사진처럼 stale 값도 유용하다면 갱신 시각을 함께 보여 준다.
중복 reload와 불필요한 작업 줄이기
Flutter 상태가 여러 번 emit될 때마다 native reload를 호출하면 실제 표시 내용이 같아도 요청이 반복된다. 위젯 projection의 fingerprint를 비교한다.
Future<void> syncWidget(AppState state) async {
final snapshot = projector.project(state);
final fingerprint = snapshot.contentFingerprint;
if (fingerprint == lastSavedFingerprint) {
return;
}
await widgetBridge.saveAndRequestReload(snapshot);
lastSavedFingerprint = fingerprint;
}
시간만 달라지고 화면 내용은 같다면 fingerprint에서 생성 시각을 제외할지 결정한다. 반대로 freshness 표시에 생성 시각이 중요하면 포함해야 한다.
여러 변경을 짧게 묶는 debounce는 가능하지만 앱이 background로 전환되기 전에 저장이 취소될 수 있다. 중요한 상태는 즉시 저장하고 reload만 합치거나, debounce task를 lifecycle 전환 때 flush하는 식으로 책임을 나눈다.
시간 경계와 타임존을 정확히 계산하기
“내일 0시”를 now + 24시간으로 계산하면 daylight saving time이 있는 지역에서 틀릴 수 있다.
func startOfNextDay(
after date: Date,
calendar: Calendar
) -> Date {
guard let result = calendar.date(
byAdding: .day,
value: 1,
to: calendar.startOfDay(for: date)
) else {
preconditionFailure("Could not calculate next day")
}
return result
}
사용자가 time zone을 바꾸면 기존 timeline의 날짜 의미가 달라질 수도 있다. 중요한 일정 위젯이라면 system time-zone 변경 후 새 timeline을 얻는 경로와 snapshot의 time-zone 의미를 정의한다.
서버 timestamp는 절대 시각으로 저장하고 표시 경계는 사용자의 Calendar와 TimeZone으로 계산한다. “서버 날짜 문자열”을 그대로 잘라 자정 로직에 사용하지 않는다.
테스트 가능한 TimelineBuilder 만들기
provider 안에서 Date()와 파일 I/O를 직접 호출하면 시간 경계 테스트가 어렵다. reader, clock, calendar를 주입하고 timeline 계산을 순수한 builder로 분리한다.
struct TimelineBuilder {
let calendar: Calendar
let freshnessPolicy: FreshnessPolicy
func build(
snapshot: WidgetSnapshot?,
now: Date
) -> Timeline<SummaryEntry> {
guard let snapshot else {
return emptyTimeline(now: now)
}
let entries = makeEntries(
snapshot: snapshot,
now: now
)
let nextBoundary = nextRefreshBoundary(
snapshot: snapshot,
now: now
)
return Timeline(
entries: entries,
policy: .after(nextBoundary)
)
}
}
테스트에서는 entry의 개수뿐 아니라 날짜 정렬, 상태 전환, policy 날짜를 검증한다.
func testBuildsDueEntryAtScheduledTime() {
let now = date("2025-12-26T09:00:00+09:00")
let due = date("2025-12-26T11:00:00+09:00")
let snapshot = WidgetSnapshot.fixture(nextEventAt: due)
let timeline = builder.build(
snapshot: snapshot,
now: now
)
XCTAssertEqual(timeline.entries.first?.date, now)
XCTAssertTrue(
timeline.entries.contains { $0.date == due }
)
}
필수 테스트 사례는 다음과 같다.
- snapshot이 없는 첫 설치
- fresh, stale, expired 경계의 바로 전과 후
- 다음 이벤트가 과거인 경우
- 같은 날짜 entry 중복 제거
- 자정, 월말, 윤년
- daylight saving time 전환 지역
- time zone 변경
- 손상된 snapshot fallback
- 외부 reload 뒤 새 revision 반영
운영 환경에서 갱신 문제 진단하기
“위젯이 안 바뀐다”는 증상만으로 원인을 알기 어렵다. 단계별 event를 민감 정보 없이 남긴다.
snapshot_commit revision=18 result=success
reload_request kind=summary reason=user_edit
timeline_build revision=18 entries=3 policy=after
snapshot_age_seconds=42 freshness=fresh
진단 순서는 다음처럼 나눌 수 있다.
- 앱이 새 revision을 App Group에 저장했는가
- 저장 뒤 올바른 widget kind로 reload했는가
- provider가 새로 실행됐는가
- provider가 어느 revision을 읽었는가
- timeline entry 날짜가 현재와 미래에 맞게 정렬됐는가
- view가 entry state를 올바르게 렌더링했는가
extension console에 민감한 title이나 사용자 식별자를 남기지 않는다. revision, schema version, age, error category만으로도 대부분의 경계를 찾을 수 있다.
simulator에서 reload가 빨라 보였다고 실제 기기의 정확한 주기를 보장한다고 문서화하지 않는다. 다양한 상태의 실제 기기에서 관찰하고, 지연되어도 의미가 깨지지 않는 UI를 만든다.
구현 체크리스트
마무리
WidgetKit timeline은 extension을 일정 간격으로 깨우는 timer 설정이 아니다. 지금 알고 있는 정보를 바탕으로 미래의 화면 상태를 제출하는 모델이다. 따라서 시간이 지나면 확실히 바뀌는 값은 여러 TimelineEntry로 미리 표현하고, 앱 편집이나 서버 이벤트처럼 예측할 수 없는 변화는 snapshot 저장 뒤 reload를 요청한다.
.atEnd, .after, .never는 각각 다음 timeline을 언제 다시 고려할지 알려 주지만 정확한 실행 시각을 보장하지 않는다. 위젯은 갱신이 늦어져도 마지막 성공 데이터를 안전하게 표시하고, 그 데이터가 stale인지 사용자에게 의미 있게 알려야 한다.
좋은 위젯 갱신 설계는 자주 실행되는 설계가 아니다. 적은 실행으로도 올바른 시점의 화면을 만들고, 실행되지 않는 시간까지 정상 상태로 포함하는 설계다.
관련 노트
- Flutter와 iOS WidgetKit 사이에 데이터 공유하기
- App Group UserDefaults와 Keychain의 역할 차이
- 앱 생명주기 변화에 안전하게 대응하기
- MethodChannel로 Flutter와 네이티브 코드 연결하기
- 재시도에 지수 백오프와 지터가 필요한 이유
- Debounce와 Throttle을 선택하는 기준